import {
  CompressedTexture,
  FileLoader,
  LinearFilter,
  LinearMipmapLinearFilter,
  Loader,
  RGB_ETC1_Format,
  RGB_ETC2_Format,
  RGB_PVRTC_4BPPV1_Format,
  RGB_S3TC_DXT1_Format,
  RGBA_ASTC_4x4_Format,
  RGBA_BPTC_Format,
  RGBA_ETC2_EAC_Format,
  RGBA_PVRTC_4BPPV1_Format,
  RGBA_S3TC_DXT5_Format,
  RGBAFormat,
  UnsignedByteType
} from '../../three.module.js';

/**
 * Loader for Basis Universal GPU Texture Codec.
 *
 * Basis Universal is a "supercompressed" GPU texture and texture video
 * compression system that outputs a highly compressed intermediate file format
 * (.basis) that can be quickly transcoded to a wide variety of GPU texture
 * compression formats.
 *
 * This loader parallelizes the transcoding process across a configurable number
 * of web workers, before transferring the transcoded compressed texture back
 * to the main thread.
 */

const _taskCache = new WeakMap();

class BasisTextureLoader extends Loader {

  constructor(manager) {

    super(manager);

    this.transcoderPath = '';
    this.transcoderBinary = null;
    this.transcoderPending = null;

    this.workerLimit = 4;
    this.workerPool = [];
    this.workerNextTaskID = 1;
    this.workerSourceURL = '';
    this.workerConfig = null;

  }

  setTranscoderPath(path) {

    this.transcoderPath = path;

    return this;

  }

  setWorkerLimit(workerLimit) {

    this.workerLimit = workerLimit;

    return this;

  }

  detectSupport(renderer) {

    this.workerConfig = {
      astcSupported: renderer.extensions.has('WEBGL_compressed_texture_astc'),
      etc1Supported: renderer.extensions.has('WEBGL_compressed_texture_etc1'),
      etc2Supported: renderer.extensions.has('WEBGL_compressed_texture_etc'),
      dxtSupported: renderer.extensions.has('WEBGL_compressed_texture_s3tc'),
      bptcSupported: renderer.extensions.has('EXT_texture_compression_bptc'),
      pvrtcSupported: renderer.extensions.has('WEBGL_compressed_texture_pvrtc')
        || renderer.extensions.has('WEBKIT_WEBGL_compressed_texture_pvrtc')
    };

    return this;

  }

  load(url, onLoad, onProgress, onError) {

    const loader = new FileLoader(this.manager);

    loader.setResponseType('arraybuffer');
    loader.setWithCredentials(this.withCredentials);

    const texture = new CompressedTexture();

    loader.load(url, (buffer) => {

      // Check for an existing task using this buffer. A transferred buffer cannot be transferred
      // again from this thread.
      if (_taskCache.has(buffer)) {

        const cachedTask = _taskCache.get(buffer);

        return cachedTask.promise.then(onLoad).catch(onError);

      }

      this._createTexture([buffer])
        .then(function (_texture) {

          texture.copy(_texture);
          texture.needsUpdate = true;

          if (onLoad) onLoad(texture);

        })
        .catch(onError);

    }, onProgress, onError);

    return texture;

  }

  /** Low-level transcoding API, exposed for use by KTX2Loader. */
  parseInternalAsync(options) {

    const {levels} = options;

    const buffers = new Set();

    for (let i = 0; i < levels.length; i++) {

      buffers.add(levels[i].data.buffer);

    }

    return this._createTexture(Array.from(buffers), {...options, lowLevel: true});

  }

  /**
   * @param {ArrayBuffer[]} buffers
   * @param {object?} config
   * @return {Promise<CompressedTexture>}
   */
  _createTexture(buffers, config = {}) {

    let worker;
    let taskID;

    const taskConfig = config;
    let taskCost = 0;

    for (let i = 0; i < buffers.length; i++) {

      taskCost += buffers[i].byteLength;

    }

    const texturePending = this._allocateWorker(taskCost)
      .then((_worker) => {

        worker = _worker;
        taskID = this.workerNextTaskID++;

        return new Promise((resolve, reject) => {

          worker._callbacks[taskID] = {resolve, reject};

          worker.postMessage({type: 'transcode', id: taskID, buffers: buffers, taskConfig: taskConfig}, buffers);

        });

      })
      .then((message) => {

        const {mipmaps, width, height, format} = message;

        const texture = new CompressedTexture(mipmaps, width, height, format, UnsignedByteType);
        texture.minFilter = mipmaps.length === 1 ? LinearFilter : LinearMipmapLinearFilter;
        texture.magFilter = LinearFilter;
        texture.generateMipmaps = false;
        texture.needsUpdate = true;

        return texture;

      });

    // Note: replaced '.finally()' with '.catch().then()' block - iOS 11 support (#19416)
    texturePending
      .catch(() => true)
      .then(() => {

        if (worker && taskID) {

          worker._taskLoad -= taskCost;
          delete worker._callbacks[taskID];

        }

      });

    // Cache the task result.
    _taskCache.set(buffers[0], {promise: texturePending});

    return texturePending;

  }

  _initTranscoder() {

    if (!this.transcoderPending) {

      // Load transcoder wrapper.
      const jsLoader = new FileLoader(this.manager);
      jsLoader.setPath(this.transcoderPath);
      jsLoader.setWithCredentials(this.withCredentials);
      const jsContent = new Promise((resolve, reject) => {

        jsLoader.load('basis_transcoder.js', resolve, undefined, reject);

      });

      // Load transcoder WASM binary.
      const binaryLoader = new FileLoader(this.manager);
      binaryLoader.setPath(this.transcoderPath);
      binaryLoader.setResponseType('arraybuffer');
      binaryLoader.setWithCredentials(this.withCredentials);
      const binaryContent = new Promise((resolve, reject) => {

        binaryLoader.load('basis_transcoder.wasm', resolve, undefined, reject);

      });

      this.transcoderPending = Promise.all([jsContent, binaryContent])
        .then(([jsContent, binaryContent]) => {

          const fn = BasisTextureLoader.BasisWorker.toString();

          const body = [
            '/* constants */',
            'let _EngineFormat = ' + JSON.stringify(BasisTextureLoader.EngineFormat),
            'let _TranscoderFormat = ' + JSON.stringify(BasisTextureLoader.TranscoderFormat),
            'let _BasisFormat = ' + JSON.stringify(BasisTextureLoader.BasisFormat),
            '/* basis_transcoder.js */',
            jsContent,
            '/* worker */',
            fn.substring(fn.indexOf('{') + 1, fn.lastIndexOf('}'))
          ].join('\n');

          this.workerSourceURL = URL.createObjectURL(new Blob([body]));
          this.transcoderBinary = binaryContent;

        });

    }

    return this.transcoderPending;

  }

  _allocateWorker(taskCost) {

    return this._initTranscoder().then(() => {

      if (this.workerPool.length < this.workerLimit) {

        const worker = new Worker(this.workerSourceURL);

        worker._callbacks = {};
        worker._taskLoad = 0;

        worker.postMessage({
          type: 'init',
          config: this.workerConfig,
          transcoderBinary: this.transcoderBinary,
        });

        worker.onmessage = function (e) {

          const message = e.data;

          switch (message.type) {

            case 'transcode':
              worker._callbacks[message.id].resolve(message);
              break;

            case 'error':
              worker._callbacks[message.id].reject(message);
              break;

            default:
              console.error('THREE.BasisTextureLoader: Unexpected message, "' + message.type + '"');

          }

        };

        this.workerPool.push(worker);

      } else {

        this.workerPool.sort(function (a, b) {

          return a._taskLoad > b._taskLoad ? -1 : 1;

        });

      }

      const worker = this.workerPool[this.workerPool.length - 1];

      worker._taskLoad += taskCost;

      return worker;

    });

  }

  dispose() {

    for (let i = 0; i < this.workerPool.length; i++) {

      this.workerPool[i].terminate();

    }

    this.workerPool.length = 0;

    return this;

  }

}

/* CONSTANTS */

BasisTextureLoader.BasisFormat = {
  ETC1S: 0,
  UASTC_4x4: 1,
};

BasisTextureLoader.TranscoderFormat = {
  ETC1: 0,
  ETC2: 1,
  BC1: 2,
  BC3: 3,
  BC4: 4,
  BC5: 5,
  BC7_M6_OPAQUE_ONLY: 6,
  BC7_M5: 7,
  PVRTC1_4_RGB: 8,
  PVRTC1_4_RGBA: 9,
  ASTC_4x4: 10,
  ATC_RGB: 11,
  ATC_RGBA_INTERPOLATED_ALPHA: 12,
  RGBA32: 13,
  RGB565: 14,
  BGR565: 15,
  RGBA4444: 16,
};

BasisTextureLoader.EngineFormat = {
  RGBAFormat: RGBAFormat,
  RGBA_ASTC_4x4_Format: RGBA_ASTC_4x4_Format,
  RGBA_BPTC_Format: RGBA_BPTC_Format,
  RGBA_ETC2_EAC_Format: RGBA_ETC2_EAC_Format,
  RGBA_PVRTC_4BPPV1_Format: RGBA_PVRTC_4BPPV1_Format,
  RGBA_S3TC_DXT5_Format: RGBA_S3TC_DXT5_Format,
  RGB_ETC1_Format: RGB_ETC1_Format,
  RGB_ETC2_Format: RGB_ETC2_Format,
  RGB_PVRTC_4BPPV1_Format: RGB_PVRTC_4BPPV1_Format,
  RGB_S3TC_DXT1_Format: RGB_S3TC_DXT1_Format,
};


/* WEB WORKER */

BasisTextureLoader.BasisWorker = function () {

  let config;
  let transcoderPending;
  let BasisModule;

  const EngineFormat = _EngineFormat; // eslint-disable-line no-undef
  const TranscoderFormat = _TranscoderFormat; // eslint-disable-line no-undef
  const BasisFormat = _BasisFormat; // eslint-disable-line no-undef

  onmessage = function (e) {

    const message = e.data;

    switch (message.type) {

      case 'init':
        config = message.config;
        init(message.transcoderBinary);
        break;

      case 'transcode':
        transcoderPending.then(() => {

          try {

            const {width, height, hasAlpha, mipmaps, format} = message.taskConfig.lowLevel
              ? transcodeLowLevel(message.taskConfig)
              : transcode(message.buffers[0]);

            const buffers = [];

            for (let i = 0; i < mipmaps.length; ++i) {

              buffers.push(mipmaps[i].data.buffer);

            }

            self.postMessage({type: 'transcode', id: message.id, width, height, hasAlpha, mipmaps, format}, buffers);

          } catch (error) {

            console.error(error);

            self.postMessage({type: 'error', id: message.id, error: error.message});

          }

        });
        break;

    }

  };

  function init(wasmBinary) {

    transcoderPending = new Promise((resolve) => {

      BasisModule = {wasmBinary, onRuntimeInitialized: resolve};
      BASIS(BasisModule); // eslint-disable-line no-undef

    }).then(() => {

      BasisModule.initializeBasis();

    });

  }

  function transcodeLowLevel(taskConfig) {

    const {basisFormat, width, height, hasAlpha} = taskConfig;

    const {transcoderFormat, engineFormat} = getTranscoderFormat(basisFormat, width, height, hasAlpha);

    const blockByteLength = BasisModule.getBytesPerBlockOrPixel(transcoderFormat);

    assert(BasisModule.isFormatSupported(transcoderFormat), 'THREE.BasisTextureLoader: Unsupported format.');

    const mipmaps = [];

    if (basisFormat === BasisFormat.ETC1S) {

      const transcoder = new BasisModule.LowLevelETC1SImageTranscoder();

      const {endpointCount, endpointsData, selectorCount, selectorsData, tablesData} = taskConfig.globalData;

      try {

        let ok;

        ok = transcoder.decodePalettes(endpointCount, endpointsData, selectorCount, selectorsData);

        assert(ok, 'THREE.BasisTextureLoader: decodePalettes() failed.');

        ok = transcoder.decodeTables(tablesData);

        assert(ok, 'THREE.BasisTextureLoader: decodeTables() failed.');

        for (let i = 0; i < taskConfig.levels.length; i++) {

          const level = taskConfig.levels[i];
          const imageDesc = taskConfig.globalData.imageDescs[i];

          const dstByteLength = getTranscodedImageByteLength(transcoderFormat, level.width, level.height);
          const dst = new Uint8Array(dstByteLength);

          ok = transcoder.transcodeImage(
            transcoderFormat,
            dst, dstByteLength / blockByteLength,
            level.data,
            getWidthInBlocks(transcoderFormat, level.width),
            getHeightInBlocks(transcoderFormat, level.height),
            level.width, level.height, level.index,
            imageDesc.rgbSliceByteOffset, imageDesc.rgbSliceByteLength,
            imageDesc.alphaSliceByteOffset, imageDesc.alphaSliceByteLength,
            imageDesc.imageFlags,
            hasAlpha,
            false,
            0, 0
          );

          assert(ok, 'THREE.BasisTextureLoader: transcodeImage() failed for level ' + level.index + '.');

          mipmaps.push({data: dst, width: level.width, height: level.height});

        }

      } finally {

        transcoder.delete();

      }

    } else {

      for (let i = 0; i < taskConfig.levels.length; i++) {

        const level = taskConfig.levels[i];

        const dstByteLength = getTranscodedImageByteLength(transcoderFormat, level.width, level.height);
        const dst = new Uint8Array(dstByteLength);

        const ok = BasisModule.transcodeUASTCImage(
          transcoderFormat,
          dst, dstByteLength / blockByteLength,
          level.data,
          getWidthInBlocks(transcoderFormat, level.width),
          getHeightInBlocks(transcoderFormat, level.height),
          level.width, level.height, level.index,
          0,
          level.data.byteLength,
          0,
          hasAlpha,
          false,
          0, 0,
          -1, -1
        );

        assert(ok, 'THREE.BasisTextureLoader: transcodeUASTCImage() failed for level ' + level.index + '.');

        mipmaps.push({data: dst, width: level.width, height: level.height});

      }

    }

    return {width, height, hasAlpha, mipmaps, format: engineFormat};

  }

  function transcode(buffer) {

    const basisFile = new BasisModule.BasisFile(new Uint8Array(buffer));

    const basisFormat = basisFile.isUASTC() ? BasisFormat.UASTC_4x4 : BasisFormat.ETC1S;
    const width = basisFile.getImageWidth(0, 0);
    const height = basisFile.getImageHeight(0, 0);
    const levels = basisFile.getNumLevels(0);
    const hasAlpha = basisFile.getHasAlpha();

    function cleanup() {

      basisFile.close();
      basisFile.delete();

    }

    const {transcoderFormat, engineFormat} = getTranscoderFormat(basisFormat, width, height, hasAlpha);

    if (!width || !height || !levels) {

      cleanup();
      throw new Error('THREE.BasisTextureLoader:	Invalid texture');

    }

    if (!basisFile.startTranscoding()) {

      cleanup();
      throw new Error('THREE.BasisTextureLoader: .startTranscoding failed');

    }

    const mipmaps = [];

    for (let mip = 0; mip < levels; mip++) {

      const mipWidth = basisFile.getImageWidth(0, mip);
      const mipHeight = basisFile.getImageHeight(0, mip);
      const dst = new Uint8Array(basisFile.getImageTranscodedSizeInBytes(0, mip, transcoderFormat));

      const status = basisFile.transcodeImage(
        dst,
        0,
        mip,
        transcoderFormat,
        0,
        hasAlpha
      );

      if (!status) {

        cleanup();
        throw new Error('THREE.BasisTextureLoader: .transcodeImage failed.');

      }

      mipmaps.push({data: dst, width: mipWidth, height: mipHeight});

    }

    cleanup();

    return {width, height, hasAlpha, mipmaps, format: engineFormat};

  }

  //

  // Optimal choice of a transcoder target format depends on the Basis format (ETC1S or UASTC),
  // device capabilities, and texture dimensions. The list below ranks the formats separately
  // for ETC1S and UASTC.
  //
  // In some cases, transcoding UASTC to RGBA32 might be preferred for higher quality (at
  // significant memory cost) compared to ETC1/2, BC1/3, and PVRTC. The transcoder currently
  // chooses RGBA32 only as a last resort and does not expose that option to the caller.
  const FORMAT_OPTIONS = [
    {
      if: 'astcSupported',
      basisFormat: [BasisFormat.UASTC_4x4],
      transcoderFormat: [TranscoderFormat.ASTC_4x4, TranscoderFormat.ASTC_4x4],
      engineFormat: [EngineFormat.RGBA_ASTC_4x4_Format, EngineFormat.RGBA_ASTC_4x4_Format],
      priorityETC1S: Infinity,
      priorityUASTC: 1,
      needsPowerOfTwo: false,
    },
    {
      if: 'bptcSupported',
      basisFormat: [BasisFormat.ETC1S, BasisFormat.UASTC_4x4],
      transcoderFormat: [TranscoderFormat.BC7_M5, TranscoderFormat.BC7_M5],
      engineFormat: [EngineFormat.RGBA_BPTC_Format, EngineFormat.RGBA_BPTC_Format],
      priorityETC1S: 3,
      priorityUASTC: 2,
      needsPowerOfTwo: false,
    },
    {
      if: 'dxtSupported',
      basisFormat: [BasisFormat.ETC1S, BasisFormat.UASTC_4x4],
      transcoderFormat: [TranscoderFormat.BC1, TranscoderFormat.BC3],
      engineFormat: [EngineFormat.RGB_S3TC_DXT1_Format, EngineFormat.RGBA_S3TC_DXT5_Format],
      priorityETC1S: 4,
      priorityUASTC: 5,
      needsPowerOfTwo: false,
    },
    {
      if: 'etc2Supported',
      basisFormat: [BasisFormat.ETC1S, BasisFormat.UASTC_4x4],
      transcoderFormat: [TranscoderFormat.ETC1, TranscoderFormat.ETC2],
      engineFormat: [EngineFormat.RGB_ETC2_Format, EngineFormat.RGBA_ETC2_EAC_Format],
      priorityETC1S: 1,
      priorityUASTC: 3,
      needsPowerOfTwo: false,
    },
    {
      if: 'etc1Supported',
      basisFormat: [BasisFormat.ETC1S, BasisFormat.UASTC_4x4],
      transcoderFormat: [TranscoderFormat.ETC1, TranscoderFormat.ETC1],
      engineFormat: [EngineFormat.RGB_ETC1_Format, EngineFormat.RGB_ETC1_Format],
      priorityETC1S: 2,
      priorityUASTC: 4,
      needsPowerOfTwo: false,
    },
    {
      if: 'pvrtcSupported',
      basisFormat: [BasisFormat.ETC1S, BasisFormat.UASTC_4x4],
      transcoderFormat: [TranscoderFormat.PVRTC1_4_RGB, TranscoderFormat.PVRTC1_4_RGBA],
      engineFormat: [EngineFormat.RGB_PVRTC_4BPPV1_Format, EngineFormat.RGBA_PVRTC_4BPPV1_Format],
      priorityETC1S: 5,
      priorityUASTC: 6,
      needsPowerOfTwo: true,
    },
  ];

  const ETC1S_OPTIONS = FORMAT_OPTIONS.sort(function (a, b) {

    return a.priorityETC1S - b.priorityETC1S;

  });
  const UASTC_OPTIONS = FORMAT_OPTIONS.sort(function (a, b) {

    return a.priorityUASTC - b.priorityUASTC;

  });

  function getTranscoderFormat(basisFormat, width, height, hasAlpha) {

    let transcoderFormat;
    let engineFormat;

    const options = basisFormat === BasisFormat.ETC1S ? ETC1S_OPTIONS : UASTC_OPTIONS;

    for (let i = 0; i < options.length; i++) {

      const opt = options[i];

      if (!config[opt.if]) continue;
      if (!opt.basisFormat.includes(basisFormat)) continue;
      if (opt.needsPowerOfTwo && !(isPowerOfTwo(width) && isPowerOfTwo(height))) continue;

      transcoderFormat = opt.transcoderFormat[hasAlpha ? 1 : 0];
      engineFormat = opt.engineFormat[hasAlpha ? 1 : 0];

      return {transcoderFormat, engineFormat};

    }

    console.warn('THREE.BasisTextureLoader: No suitable compressed texture format found. Decoding to RGBA32.');

    transcoderFormat = TranscoderFormat.RGBA32;
    engineFormat = EngineFormat.RGBAFormat;

    return {transcoderFormat, engineFormat};

  }

  function assert(ok, message) {

    if (!ok) throw new Error(message);

  }

  function getWidthInBlocks(transcoderFormat, width) {

    return Math.ceil(width / BasisModule.getFormatBlockWidth(transcoderFormat));

  }

  function getHeightInBlocks(transcoderFormat, height) {

    return Math.ceil(height / BasisModule.getFormatBlockHeight(transcoderFormat));

  }

  function getTranscodedImageByteLength(transcoderFormat, width, height) {

    const blockByteLength = BasisModule.getBytesPerBlockOrPixel(transcoderFormat);

    if (BasisModule.formatIsUncompressed(transcoderFormat)) {

      return width * height * blockByteLength;

    }

    if (transcoderFormat === TranscoderFormat.PVRTC1_4_RGB
      || transcoderFormat === TranscoderFormat.PVRTC1_4_RGBA) {

      // GL requires extra padding for very small textures:
      // https://www.khronos.org/registry/OpenGL/extensions/IMG/IMG_texture_compression_pvrtc.txt
      const paddedWidth = (width + 3) & ~3;
      const paddedHeight = (height + 3) & ~3;

      return (Math.max(8, paddedWidth) * Math.max(8, paddedHeight) * 4 + 7) / 8;

    }

    return (getWidthInBlocks(transcoderFormat, width)
      * getHeightInBlocks(transcoderFormat, height)
      * blockByteLength);

  }

  function isPowerOfTwo(value) {

    if (value <= 2) return true;

    return (value & (value - 1)) === 0 && value !== 0;

  }

};

export {BasisTextureLoader};
